Skip to content

Add configurable studio sidebar navigation - #407

Closed
jackgranatowski wants to merge 1 commit into
mainfrom
codex/refactor-nawigacje-w-studioframe.svelte
Closed

Add configurable studio sidebar navigation#407
jackgranatowski wants to merge 1 commit into
mainfrom
codex/refactor-nawigacje-w-studioframe.svelte

Conversation

@jackgranatowski

Copy link
Copy Markdown
Contributor

Motivation

  • Replace the hard-coded "01 Preview / 02 Tune / 03 Verify" workflow in the studio frame with a flexible API so each Studio can provide its own sections and surface panels.
  • Allow Studios to supply a custom sidebar or nav data and to control (or observe) the currently active panel from the parent.
  • Eliminate duplicated workflow markup/styles across individual Studio components and make the left column contain meaningful working sections (not a static list).

Description

  • Introduced a configurable StudioFrame API: accepts nav, activePanel, onSelectPanel and sidebar props and exposes an internal active state; added slug/id generation for stable aria targets and tab ids (file: configurator/src/components/editors/StudioFrame.svelte).
  • Implemented accessible tablist/tabpanel wiring in StudioFrame using role="tablist" / role="tab" / aria-selected / aria-controls and role="tabpanel" on the surface, and vertical tab styling for the left column (file: configurator/src/components/editors/StudioFrame.svelte).
  • Updated Studios to pass nav instead of rendering their own workflow: ColorStudio, SpacingStudio, LayoutStudio, MotionStudio, ShapeStudio, EffectsStudio, ShadowStudio, and TypographyStudio now provide nav (files under configurator/src/components/editors/*Studio.svelte).
  • Converted Typography Studio to use activePanel / onSelectPanel with TYPOGRAPHY_PANELS and removed its local tab toolbar, and added realistic left-column sections for Color Studio (Main colors, Semantic colors, Gradients, Shade curve, Contrast, Assignments) (files: TypographyStudio.svelte, ColorStudio.svelte).
  • Removed the duplicated StudioWorkflow.svelte component and trimmed per-studio workflow CSS that is now handled by StudioFrame (file removed: configurator/src/components/editors/StudioWorkflow.svelte).

Testing

  • Ran npm run check (Svelte diagnostics) which completed with 0 errors and only unrelated warnings.
  • Built the configurator with npm run build (Vite) which completed successfully; build emitted non-blocking warnings about chunk sizes and unrelated deprecated usage.
  • Ran git diff --check to ensure no whitespace/patch issues (no problems reported).

Codex Task

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jackgranatowski, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 28 minutes and 56 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 96c460a7-f94c-413d-a5aa-aa7ec7236e9d

📥 Commits

Reviewing files that changed from the base of the PR and between f391ec2 and 3215377.

📒 Files selected for processing (10)
  • configurator/src/components/editors/ColorStudio.svelte
  • configurator/src/components/editors/EffectsStudio.svelte
  • configurator/src/components/editors/LayoutStudio.svelte
  • configurator/src/components/editors/MotionStudio.svelte
  • configurator/src/components/editors/ShadowStudio.svelte
  • configurator/src/components/editors/ShapeStudio.svelte
  • configurator/src/components/editors/SpacingStudio.svelte
  • configurator/src/components/editors/StudioFrame.svelte
  • configurator/src/components/editors/StudioWorkflow.svelte
  • configurator/src/components/editors/TypographyStudio.svelte
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/refactor-nawigacje-w-studioframe.svelte

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make StudioFrame sidebar navigation configurable via accessible vertical tabs
✨ Enhancement 🕐 20-40 Minutes

Grey Divider

Description

• Replace hard-coded StudioFrame steps with configurable nav + optional custom sidebar.
• Add tablist/tabpanel ARIA wiring and stable slug-based ids for panels.
• Update studios to pass nav and remove duplicated workflow markup/styles.
Diagram

graph TD
  A["*Studio.svelte"] -->|"nav / activePanel / onSelectPanel"| B["StudioFrame.svelte"] --> C{"custom sidebar?"} -->|"yes"| D["sidebar render fn"] --> F["Surface (tabpanel)"]
  C -->|"no"| E["Built-in vertical tabs"] --> F
  H["Parent state"] -->|"controls/observes"| B
  B --> G(("internalActivePanel"))

  subgraph Legend
    direction LR
    _cmp["Component"] ~~~ _dec{"Decision"} ~~~ _st(("State"))
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Render one tabpanel per nav item (true tabs)
  • aria-controls always points to an existing element
  • ➕ Supports correct keyboard/tab semantics (including hidden panels)
  • ➕ Allows StudioFrame to own panel switching rather than relying on children
  • ➖ Requires a slot/per-panel render API (bigger surface-area change)
  • ➖ May force refactors in studios to split content by panel
2. Use navigation semantics instead of tabs (no tablist/tabpanel roles)
  • ➕ Avoids mismatched ARIA if there isn’t a real per-panel DOM panel
  • ➕ Simpler implementation while still allowing parent-controlled active section
  • ➖ Loses tab semantics and expectations (keyboard arrows, selected tab concept)
  • ➖ Less consistent with TypographyStudio’s panel model
3. Adopt a headless a11y tabs helper (roving tabindex + arrow keys)
  • ➕ Standardized keyboard handling and focus management
  • ➕ Reduces risk of subtle ARIA/tabindex mistakes
  • ➖ Adds dependency/utility layer and integration work
  • ➖ May be overkill for a small in-app component set

Recommendation: The centralization and nav/sidebar API are solid and remove duplication. The main concern to double-check is ARIA correctness: the current approach uses a single surface element whose id changes with selection, while each tab’s aria-controls points to a panel-specific id—meaning inactive tabs may reference elements not present in the DOM. If the intention is “true tabs,” prefer one persistent tabpanel per nav entry (hidden when inactive). If the intention is “navigation/section selection” without a real tabpanel per section, consider switching to simpler nav semantics (e.g., aria-current) and avoid tab roles.

Files changed (9) +78 / -70

Enhancement (1) +58 / -8
StudioFrame.svelteAdd configurable nav/sidebar API with tablist/tabpanel wiring +58/-8

Add configurable nav/sidebar API with tablist/tabpanel wiring

• Extends StudioFrame props to accept 'nav', optional controlled 'activePanel'/'onSelectPanel', and an optional 'sidebar' render function. Adds internal active state, slug/id generation for stable tab/panel identifiers, and renders a vertical tablist when no custom sidebar is provided.

configurator/src/components/editors/StudioFrame.svelte

Refactor (8) +20 / -62
ColorStudio.svelteProvide nav sections to StudioFrame and remove local workflow UI +4/-10

Provide nav sections to StudioFrame and remove local workflow UI

• Replaces the hard-coded workflow nav with a 'nav' array passed into StudioFrame. Removes workflow-related markup and CSS, and adjusts responsive rules accordingly.

configurator/src/components/editors/ColorStudio.svelte

EffectsStudio.svelteReplace StudioWorkflow usage with StudioFrame nav prop +2/-4

Replace StudioWorkflow usage with StudioFrame nav prop

• Drops the shared StudioWorkflow component and provides the same steps via 'nav' to StudioFrame. Keeps the rest of the studio content intact.

configurator/src/components/editors/EffectsStudio.svelte

LayoutStudio.svelteMove layout workflow steps into StudioFrame nav +3/-13

Move layout workflow steps into StudioFrame nav

• Removes the local workflow '<nav>' markup/CSS and instead passes 'nav' into StudioFrame. Simplifies responsive CSS now that workflow layout is centralized.

configurator/src/components/editors/LayoutStudio.svelte

MotionStudio.svelteRemove StudioWorkflow and supply nav to StudioFrame +2/-4

Remove StudioWorkflow and supply nav to StudioFrame

• Eliminates StudioWorkflow import/usage and passes the motion steps as 'nav' to StudioFrame. No behavioral changes beyond navigation rendering ownership.

configurator/src/components/editors/MotionStudio.svelte

ShadowStudio.svelteRemove StudioWorkflow and supply nav to StudioFrame +2/-4

Remove StudioWorkflow and supply nav to StudioFrame

• Replaces the workflow component with a 'nav' array passed into StudioFrame, centralizing sidebar navigation styling/structure.

configurator/src/components/editors/ShadowStudio.svelte

ShapeStudio.svelteRemove StudioWorkflow and supply nav to StudioFrame +2/-4

Remove StudioWorkflow and supply nav to StudioFrame

• Moves workflow steps into 'nav' for StudioFrame and removes StudioWorkflow integration, aligning shape studio with the new shared frame API.

configurator/src/components/editors/ShapeStudio.svelte

SpacingStudio.svelteReplace local workflow grid with StudioFrame nav +2/-9

Replace local workflow grid with StudioFrame nav

• Removes in-component workflow markup and styling and passes the sections via 'nav' to StudioFrame. Simplifies responsive CSS by dropping workflow-specific rules.

configurator/src/components/editors/SpacingStudio.svelte

TypographyStudio.svelteDelegate typography panel tabs to StudioFrame via controlled activePanel +3/-14

Delegate typography panel tabs to StudioFrame via controlled activePanel

• Removes the local toolbar tab UI and passes 'TYPOGRAPHY_PANELS' to StudioFrame as 'nav', using 'activePanel'/'onSelectPanel' to keep control in TypographyStudio. Keeps the panel description visible as standalone text.

configurator/src/components/editors/TypographyStudio.svelte

@qodo-code-review

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: Configurator tests

Failed stage: Run npm test [❌]

Failed test name: tests-components/studios.test.js > visual studios > Color Studio mounts with preview and curated controls; tests-components/studios.test.js > visual studios > Typography Studio switches scopes and renders active panel controls

Failure summary:

The GitHub Action failed because the Vitest component test run had 2 failing tests in
tests-components/studios.test.js, causing the process to exit with code 1.

Failures:
- tests-components/studios.test.js > visual studios > Color Studio mounts with preview and
curated controls failed with TestingLibraryElementError because the test could not find an element
containing the text Source pairs (see tests-components/studios.test.js:43:16 where
getAllByText(text) is asserted).
- tests-components/studios.test.js > visual studios > Typography
Studio switches scopes and renders active panel controls failed with TestingLibraryElementError
because the test could not find an accessible element with role tab and name Headings (see
tests-components/studios.test.js:50:27 where getByRole('tab', { name: 'Headings' }) is used).

These errors indicate the rendered UI no longer includes the expected text/accessible tab label (or
it is split/changed), so Testing Library queries fail.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

1096:  type: 'test'
1097:  ...
1098:  1..3
1099:  ok 28 - bestContrastVsBW
1100:  ---
1101:  duration_ms: 0.63344
1102:  type: 'suite'
1103:  ...
1104:  # Subtest: wcagLevel
1105:  # Subtest: classifies known thresholds
1106:  ok 1 - classifies known thresholds
1107:  ---
1108:  duration_ms: 0.163761
1109:  type: 'test'
1110:  ...
1111:  # Subtest: non-finite ratios fail
1112:  ok 2 - non-finite ratios fail
1113:  ---
...

3965:  ok 7 - storage key is versioned
3966:  ---
3967:  duration_ms: 0.229857
3968:  type: 'test'
3969:  ...
3970:  1..7
3971:  ok 88 - sanitiseUiState
3972:  ---
3973:  duration_ms: 3.836332
3974:  type: 'suite'
3975:  ...
3976:  1..88
3977:  # tests 525
3978:  # suites 88
3979:  # pass 525
3980:  # fail 0
3981:  # cancelled 0
3982:  # skipped 0
3983:  # todo 0
3984:  # duration_ms 1020.120337
3985:  > slashed-configurator@0.6.15 test:components
3986:  > vitest run
3987:  �[1m�[46m RUN �[49m�[22m �[36mv3.2.6 �[39m�[90m/home/runner/work/SLASHED/SLASHED/configurator�[39m
3988:  �[32m✓�[39m tests-components/icon.test.js �[2m(�[22m�[2m5 tests�[22m�[2m)�[22m�[32m 32�[2mms�[22m�[39m
3989:  9:52:21 AM [vite-plugin-svelte] src/components/ControlSection.svelte:3:47 This reference only captures the initial value of `defaultOpen`. Did you mean to reference it inside a derived instead?
3990:  https://svelte.dev/e/state_referenced_locally
3991:  �[32m✓�[39m tests-components/domain-preview.test.js �[2m(�[22m�[2m12 tests�[22m�[2m)�[22m�[33m 1128�[2mms�[22m�[39m
3992:  �[32m✓�[39m tests-components/bundle-picker.test.js �[2m(�[22m�[2m4 tests�[22m�[2m)�[22m�[32m 263�[2mms�[22m�[39m
3993:  �[32m✓�[39m tests-components/share-button.test.js �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[32m 89�[2mms�[22m�[39m
3994:  �[32m✓�[39m tests-components/shade-ramp.test.js �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[33m 479�[2mms�[22m�[39m
3995:  �[33m�[2m✓�[22m�[39m ShadeRamp�[2m > �[22mrenders the six brand rows with seven swatches each �[33m 308�[2mms�[22m�[39m
3996:  �[31m❯�[39m tests-components/studios.test.js �[2m(�[22m�[2m10 tests�[22m�[2m | �[22m�[31m2 failed�[39m�[2m)�[22m�[33m 8242�[2mms�[22m�[39m
3997:  �[33m�[2m✓�[22m�[39m visual studios�[2m > �[22mTypography Studio mounts with preview and curated controls �[33m 862�[2mms�[22m�[39m
...

4613:  --sf-color-action-superlight: var(--sf-color-action-50);
4614:  --sf-color-action-xdark: var(--sf-color-action-800);
4615:  --sf-color-action-xlight: var(--sf-color-action-200);
4616:  --sf-color-base: var(--sf-color-base-source-light);
4617:  --sf-color-base--active: var(--sf-color-base-xdark);
4618:  --sf-color-base--hover: var(--sf-color-base-darker);
4619:  --sf-color-base-100: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-100), var(--sf-color-base));
4620:  --sf-color-base-200: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-200), var(--sf-color-base));
4621:  --sf-color-base-300: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-300), var(--sf-color-base));
4622:  --sf-color-base-400: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-400), var(--sf-color-base));
4623:  --sf-color-base-50: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-50), var(--sf-color-base));
4624:  --sf-color-base-500: var(--sf-color-base);
4625:  --sf-color-base-600: color-mix(in oklab, var(--sf-color-base) var(--sf-palette-mix-600), var(--sf-color-text));
4626:  --sf-color-base-700: color-mix(in oklab, var(--sf-color-base) va...�[39m
4627:  �[32m✓�[39m visual studios�[2m > �[22mFriendlyControl renders schema select controls and writes overrides�[32m 123�[2mms�[22m�[39m
4628:  �[31m⎯⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Tests 2 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m
4629:  �[41m�[1m FAIL �[22m�[49m tests-components/studios.test.js�[2m > �[22mvisual studios�[2m > �[22mColor Studio mounts with preview and curated controls
4630:  �[31m�[1mTestingLibraryElementError�[22m�[39m: Unable to find an element with the text: Source pairs. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
4631:  Ignored nodes: comments, script, style
4632:  �[36m<body>�[39m
4633:  �[36m<div>�[39m
4634:  �[36m<section�[39m
4635:  �[33mclass�[39m=�[32m"studio studio--color svelte-10uh662"�[39m
4636:  �[33mstyle�[39m=�[32m"color-scheme: light; --sf-is-dark: 0; --sf-alternate-gap: var(--sf-content-gap); --sf-alternate-inner-gap: var(--sf-gap); --sf-animation-blink: sf-blink calc(1s * var(--sf-motion-scale)) steps(1, end) infinite; --sf-animation-color-pulse: sf-color-pulse var(--sf-duration-slow) var(--sf-ease-in-out) infinite; --sf-animation-delay-1: calc(75ms * var(--sf-motion-scale)); --sf-animation-delay-2: calc(150ms * var(--sf-motion-scale)); --sf-animation-delay-3: calc(225ms * var(--sf-motion-scale)); --sf-animation-delay-4: calc(300ms * var(--sf-motion-scale)); --sf-animation-delay-5: calc(375ms * var(--sf-motion-scale)); --sf-animation-fade-in: sf-fade-in var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-fade-out: sf-fade-out var(--sf-duration-normal) var(--sf-ease-in) both; --sf-animation-float: sf-float calc(3s * var(--sf-motion-scale)) var(--sf-ease-in-out) infinite; --sf-animation-ping: sf-ping var(--sf-duration-slow) var(--sf-ease-out) infinite; --sf-animation-scale-down: sf-scale-down var(--sf-duration-normal) var(--sf-ease-in) both; --sf-animation-scale-up: sf-scale-up var(--sf-duration-normal) var(--sf-ease-overshoot) both; --sf-animation-shimmer: sf-shimmer calc(1.5s * var(--sf-motion-scale)) var(--sf-ease-in-out) infinite; --sf-animation-slide-in-down: sf-slide-in-down var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-slide-in-left: sf-slide-in-left var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-slide-in-right: sf-slide-in-right var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-slide-in-up: sf-slide-in-up var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-spin: sf-spin var(--sf-duration-slower) linear infinite; --sf-aspect: 16 / 9; --sf-bento-cols-default: 4; --sf-bento-gap: var(--sf-gap); --sf-bento-row-compact: 6rem; --sf-bento-row-default: 10rem; --sf-bento-row-tall: 16rem; --sf-blur: 12px; --sf-body-color: var(--sf-color-text); --sf-body-em-style: italic; --sf-body-font-family: var(--sf-font-body); --sf-body-font-size: var(--sf-text-m); --sf-body-font-weight: var(--sf-font-weight-body); --sf-body-line-height: var(--sf-leading-normal); --sf-body-strong-weight: var(--sf-font-weight-strong); --sf-body-text-wrap: pretty; --sf-border: var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border); --sf-border-scale: 1; --sf-border-strong: var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border--strong); --sf-border-style: solid; --sf-border-subtle: var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border--subtle); --sf-border-width-1: calc(1px * var(--sf-border-scale, 1)); --sf-border-width-2: calc(2px * var(--sf-border-scale, 1)); --sf-border-width-3: calc(3px * var(--sf-border-scale, 1)); --sf-border-width-4: calc(4px * var(--sf-border-scale, 1)); --sf-border-width-hairline: 0.5px; --sf-box-border-color: var(--sf-color-border); --sf-box-border-width: 0; --sf-box-padding: var(--sf-space-m); --sf-breakout-width: var(--sf-container-wide); --sf-button-padding-block: var(--sf-space-xs); --sf-button-padding-inline: var(--sf-space-m); --sf-button-radius: var(--sf-radius-m); --sf-caret-color: var(--sf-color-action); --sf-center-gutter: var(--sf-gutter); --sf-center-max: var(--sf-container-default); --sf-cluster-align: center; --sf-cluster-gap: var(--sf-gap); --sf-cluster-justify: flex-start; --sf-code-font-size: 0.875em; --sf-color-action: var(--sf-color-action-source-light); --sf-color-action--active: var(--sf-color-action-xdark); --sf-color-action--hover: var(--sf-color-action-darker); --sf-color-action-100: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-100), var(--sf-color-surface)); --sf-color-action-200: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-200), var(--sf-color-surface)); --sf-color-action-300: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-300), var(--sf-color-surface)); --sf-color-action-400: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-400), var(--sf-color-surface)); --sf-color-action-50: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-50), var(--sf-color-surface)); --sf-color-action-500: var(--sf-color-action); --sf-color-action-600: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-600), var(--sf-color-text)); --sf-color-action-700: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-700), var(--sf-color-text)); --sf-color-action-800: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-800), var(--sf-color-text)); --sf-color-action-900: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-900), var(--sf-color-text)); --sf-color-action-950: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-950), var(--sf-color-text)); --sf-color-action-a10: oklch(from var(--sf-color-action) l c h / 0.10); --sf-color-action-a30: oklch(from var(--sf-color-action) l c h / 0.30); --sf-color-action-a5: oklch(from var(--sf-color-action) l c h / 0.05); --sf-color-action-a50: oklch(from var(--sf-color-action) l c h / 0.50); --sf-color-action-a80: oklch(from var(--sf-color-action) l c h / 0.80); --sf-color-action-darker: var(--sf-color-action-600); --sf-color-action-ghost: oklch(from var(--sf-color-action) l c h / 0.05); --sf-color-action-lighter: var(--sf-color-action-400); --sf-color-action-muted: oklch(from var(--sf-color-action) l c h / 0.30); --sf-color-action-source-dark: oklch(0.70 0.198 235); --sf-color-action-source-light: oklch(0.50 0.22 235); --sf-color-action-subtle: oklch(from var(--sf-color-action) l c h / 0.10); --sf-color-action-superdark: var(--sf-color-action-950); --sf-color-action-superlight: var(--sf-color-action-50); --sf-color-action-xdark: var(--sf-color-action-800); --sf-color-action-xlight: var(--sf-color-action-200); --sf-color-base: var(--sf-color-base-source-light); --sf-color-base--active: var(--sf-color-base-xdark); --sf-color-base--hover: var(--sf-color-base-darker); --sf-color-base-100: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-100), var(--sf-color-base)); --sf-color-base-200: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-200), var(--sf-color-base)); --sf-color-base-300: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-300), var(--sf-color-base)); --sf-color-base-400: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-400), var(--sf-color-base)); --sf-color-base-50: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-50), var(--sf-color-base)); --sf-color-base-500: var(--sf-color-base); --sf-color-base-600: color-mix(in oklab, var(--sf-color-base) var(--sf-palette-mix-600), var(--sf-color-text)); --sf-color-base-700: color-mix(in oklab, var(--sf-color-base) var(--sf-palette-mix-700), var(--sf-color-text)); --sf-color-base-800: color-mix(in oklab, var(--sf-color-base...
4637:  �[90m �[2m❯�[22m Object.getElementError node_modules/@testing-library/dom/dist/config.js:�[2m37:19�[22m�[39m
4638:  �[90m �[2m❯�[22m node_modules/@testing-library/dom/dist/query-helpers.js:�[2m76:38�[22m�[39m
4639:  �[90m �[2m❯�[22m node_modules/@testing-library/dom/dist/query-helpers.js:�[2m109:15�[22m�[39m
4640:  �[36m �[2m❯�[22m tests-components/studios.test.js:�[2m43:16�[22m�[39m
4641:  �[90m 41| �[39m      �[34mexpect�[39m(�[34mgetByText�[39m(name))�[33m.�[39m�[34mtoBeInTheDocument�[39m()�[33m;�[39m
4642:  �[90m 42| �[39m      �[35mfor�[39m (�[35mconst�[39m text �[35mof�[39m expectedText) {
4643:  �[90m 43| �[39m        �[34mexpect�[39m(�[34mgetAllByText�[39m(text)�[33m.�[39mlength)�[33m.�[39m�[34mtoBeGreaterThan�[39m(�[34m0�[39m)�[33m;�[39m
4644:  �[90m   | �[39m               �[31m^�[39m
4645:  �[90m 44| �[39m      }
4646:  �[90m 45| �[39m    })�[33m;�[39m
4647:  �[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯�[22m�[39m
4648:  �[41m�[1m FAIL �[22m�[49m tests-components/studios.test.js�[2m > �[22mvisual studios�[2m > �[22mTypography Studio switches scopes and renders active panel controls
4649:  �[31m�[1mTestingLibraryElementError�[22m�[39m: Unable to find an accessible element with the role "tab" and name "Headings"
4650:  Here are the accessible roles:
...

5248:  --sf-color-action-superdark: var(--sf-color-action-950);
5249:  --sf-color-action-superlight: var(--sf-color-action-50);
5250:  --sf-color-action-xdark: var(--sf-color-action-800);
5251:  --sf-color-action-xlight: var(--sf-color-action-200);
5252:  --sf-color-base: var(--sf-color-base-source-light);
5253:  --sf-color-base--active: var(--sf-color-base-xdark);
5254:  --sf-color-base--hover: var(--sf-color-base-darker);
5255:  --sf-color-base-100: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-100), var(--sf-color-base));
5256:  --sf-color-base-200: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-200), var(--sf-color-base));
5257:  --sf-color-base-300: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-300), var(--sf-color-base));
5258:  --sf-color-base-400: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-400), var(--sf-color-base));
5259:  --sf-color-base-50: color-mix(in oklab, var(--sf-color-text) var(--sf-palette-mix-50), var(--sf-color-base));
5260:  --sf-color-base-500: var(--sf-color-base);
5261:  --sf-color-base-600: color-mix(in oklab, var(--sf-color-base) var(--sf-palette-mix-600), var(--sf-color-text));
5262:  --sf-color-base-700: color-mix(in oklab, var(--sf-color-base) va...
5263:  �[90m �[2m❯�[22m Object.getElementError node_modules/@testing-library/dom/dist/config.js:�[2m37:19�[22m�[39m
5264:  �[90m �[2m❯�[22m node_modules/@testing-library/dom/dist/query-helpers.js:�[2m76:38�[22m�[39m
5265:  �[90m �[2m❯�[22m node_modules/@testing-library/dom/dist/query-helpers.js:�[2m52:17�[22m�[39m
5266:  �[90m �[2m❯�[22m node_modules/@testing-library/dom/dist/query-helpers.js:�[2m95:19�[22m�[39m
5267:  �[36m �[2m❯�[22m tests-components/studios.test.js:�[2m50:27�[22m�[39m
5268:  �[90m 48| �[39m  test('Typography Studio switches scopes and renders active panel con…
5269:  �[90m 49| �[39m    const { getByRole, getByText, getAllByText } = render(TypographySt…
5270:  �[90m 50| �[39m    �[35mawait�[39m fireEvent�[33m.�[39m�[34mclick�[39m(�[34mgetByRole�[39m(�[32m'tab'�[39m�[33m,�[39m { name�[33m:�[39m �[32m'Headings'�[39m }))�[33m;�[39m
5271:  �[90m   | �[39m                          �[31m^�[39m
5272:  �[90m 51| �[39m    expect(getAllByText('Heading aliases, line-height, tracking and ma…
5273:  �[90m 52| �[39m    �[34mexpect�[39m(�[34mgetByText�[39m(�[32m'--sf-h1-size'�[39m))�[33m.�[39m�[34mtoBeInTheDocument�[39m()�[33m;�[39m
5274:  �[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯�[22m�[39m
5275:  �[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[2m | �[22m�[1m�[32m5 passed�[39m�[22m�[90m (6)�[39m
5276:  �[2m      Tests �[22m �[1m�[31m2 failed�[39m�[22m�[2m | �[22m�[1m�[32m33 passed�[39m�[22m�[90m (35)�[39m
5277:  �[2m   Start at �[22m 09:52:18
5278:  �[2m   Duration �[22m 10.88s�[2m (transform 1.65s, setup 426ms, collect 4.58s, tests 10.23s, environment 3.11s, prepare 636ms)�[22m
5279:  ##[error]TestingLibraryElementError: Unable to find an element with the text: Source pairs. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
5280:  
5281:  Ignored nodes: comments, script, style
5282:  <body>
5283:    <div>
5284:      <section
5285:        class="studio studio--color svelte-10uh662"
5286:        style="color-scheme: light; --sf-is-dark: 0; --sf-alternate-gap: var(--sf-content-gap); --sf-alternate-inner-gap: var(--sf-gap); --sf-animation-blink: sf-blink calc(1s * var(--sf-motion-scale)) steps(1, end) infinite; --sf-animation-color-pulse: sf-color-pulse var(--sf-duration-slow) var(--sf-ease-in-out) infinite; --sf-animation-delay-1: calc(75ms * var(--sf-motion-scale)); --sf-animation-delay-2: calc(150ms * var(--sf-motion-scale)); --sf-animation-delay-3: calc(225ms * var(--sf-motion-scale)); --sf-animation-delay-4: calc(300ms * var(--sf-motion-scale)); --sf-animation-delay-5: calc(375ms * var(--sf-motion-scale)); --sf-animation-fade-in: sf-fade-in var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-fade-out: sf-fade-out var(--sf-duration-normal) var(--sf-ease-in) both; --sf-animation-float: sf-float calc(3s * var(--sf-motion-scale)) var(--sf-ease-in-out) infinite; --sf-animation-ping: sf-ping var(--sf-duration-slow) var(--sf-ease-out) infinite; --sf-animation-scale-down: sf-scale-down var(--sf-duration-normal) var(--sf-ease-in) both; --sf-animation-scale-up: sf-scale-up var(--sf-duration-normal) var(--sf-ease-overshoot) both; --sf-animation-shimmer: sf-shimmer calc(1.5s * var(--sf-motion-scale)) var(--sf-ease-in-out) infinite; --sf-animation-slide-in-down: sf-slide-in-down var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-slide-in-left: sf-slide-in-left var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-slide-in-right: sf-slide-in-right var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-slide-in-up: sf-slide-in-up var(--sf-duration-normal) var(--sf-ease-out) both; --sf-animation-spin: sf-spin var(--sf-duration-slower) linear infinite; --sf-aspect: 16 / 9; --sf-bento-cols-default: 4; --sf-bento-gap: var(--sf-gap); --sf-bento-row-compact: 6rem; --sf-bento-row-default: 10rem; --sf-bento-row-tall: 16rem; --sf-blur: 12px; --sf-body-color: var(--sf-color-text); --sf-body-em-style: italic; --sf-body-font-family: var(--sf-font-body); --sf-body-font-size: var(--sf-text-m); --sf-body-font-weight: var(--sf-font-weight-body); --sf-body-line-height: var(--sf-leading-normal); --sf-body-strong-weight: var(--sf-font-weight-strong); --sf-body-text-wrap: pretty; --sf-border: var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border); --sf-border-scale: 1; --sf-border-strong: var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border--strong); --sf-border-style: solid; --sf-border-subtle: var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border--subtle); --sf-border-width-1: calc(1px * var(--sf-border-scale, 1)); --sf-border-width-2: calc(2px * var(--sf-border-scale, 1)); --sf-border-width-3: calc(3px * var(--sf-border-scale, 1)); --sf-border-width-4: calc(4px * var(--sf-border-scale, 1)); --sf-border-width-hairline: 0.5px; --sf-box-border-color: var(--sf-color-border); --sf-box-border-width: 0; --sf-box-padding: var(--sf-space-m); --sf-breakout-width: var(--sf-container-wide); --sf-button-padding-block: var(--sf-space-xs); --sf-button-padding-inline: var(--sf-space-m); --sf-button-radius: var(--sf-radius-m); --sf-caret-color: var(--sf-color-action); --sf-center-gutter: var(--sf-gutter); --sf-center-max: var(--sf-container-default); --sf-cluster-align: center; --sf-cluster-gap: var(--sf-gap); --sf-cluster-justify: flex-start; --sf-code-font-size: 0.875em; --sf-color-action: var(--sf-color-action-source-light); --sf-color-action--active: var(--sf-color-action-xdark); --sf-color-action--hover: var(--sf-color-action-darker); --sf-color-action-100: color-mix(in oklab, var(--sf-color-action) var(--sf-palette-mix-100), var(--sf-color-surface)); --sf-color-action-200: color-mix(in o
5287:  ##[error]TestingLibraryElementError: Unable to find an accessible element with the role "tab" and name "Headings"
5288:  
...

5501:      type="button"
5502:    />
5503:  
5504:    Name "05 Code":
5505:    <button
5506:      aria-controls="studio-panel-typography-studio-code"
5507:      aria-selected="false"
5508:      class="svelte-10uh662"
5509:      id="studio-tab-typography-studio-code"
5510:      role="tab"
5511:      tabindex="-1"
5512:      type="button"
5513:    />
5514:  
5515:    
5516:  ##[error]Process completed with exit code 1.
5517:  Post job cleanup.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 5 rules

Grey Divider


Action required

1. Tabs reference missing panels 🐞 Bug ≡ Correctness
Description
StudioFrame sets each tab’s aria-controls to a panel-specific id, but only renders a single tabpanel
whose id changes with the currently selected panel, so non-selected tabs point to elements that
don’t exist in the DOM. If a nav item provides panelId, aria-controls can also diverge because the
rendered tabpanel id is not derived from panel.panelId.
Code

configurator/src/components/editors/StudioFrame.svelte[R16-63]

+  const panelNav = $derived(nav.map((item) => (typeof item === 'string' ? { id: item, label: item } : item)));
+  const frameSlug = $derived(String(title).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase());
+  const firstPanel = $derived(panelNav[0]?.id);
+  const selectedPanel = $derived(activePanel ?? internalActivePanel ?? firstPanel);
+  const selectedPanelSlug = $derived(selectedPanel ? String(selectedPanel).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase() : undefined);
+  const panelId = $derived(selectedPanelSlug ? `studio-panel-${frameSlug}-${selectedPanelSlug}` : undefined);
  const stageStyle = $derived(buildPreviewDeclarations(overrides, ui.previewTheme));
+
+  function selectPanel(panel) {
+    internalActivePanel = panel.id;
+    onSelectPanel?.(panel.id, panel);
+  }
+
+  function getPanelSlug(panel) {
+    return String(panel.id).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
+  }
</script>

<section class="studio studio--{tone}" style={stageStyle}>
  <div class="studio__copy">
    <p>{eyebrow}</p>
    <h3>{title}</h3>
    {#if description}<span>{description}</span>{/if}
-    <ol class="studio__steps" aria-label="Studio workflow">
-      <li>01 Preview</li><li>02 Tune</li><li>03 Verify</li>
-    </ol>
+    {#if sidebar}
+      {@render sidebar?.({ activePanel: selectedPanel, panelId })}
+    {:else if panelNav.length}
+      <nav class="studio__nav" aria-label={`${title} sections`}>
+        <div class="studio__tabs" role="tablist" aria-orientation="vertical" aria-label={`${title} sections`}>
+          {#each panelNav as panel, index (panel.id)}
+            <button
+              type="button"
+              role="tab"
+              class:active={selectedPanel === panel.id}
+              aria-selected={selectedPanel === panel.id}
+              aria-controls={panel.panelId ?? `studio-panel-${frameSlug}-${getPanelSlug(panel)}`}
+              id={`studio-tab-${frameSlug}-${getPanelSlug(panel)}`}
+              tabindex={selectedPanel === panel.id ? 0 : -1}
+              onclick={() => selectPanel(panel)}
+            >
+              <b>{String(index + 1).padStart(2, '0')}</b>
+              <span>{panel.label}</span>
+            </button>
+          {/each}
+        </div>
+      </nav>
+    {/if}
  </div>
-  <div class="studio__surface">{@render children?.()}</div>
+  <div class="studio__surface" role="tabpanel" id={panelId} aria-labelledby={selectedPanelSlug ? `studio-tab-${frameSlug}-${selectedPanelSlug}` : undefined}>{@render children?.()}</div>
Relevance

⭐⭐ Medium

No prior reviews on tabpanel-id mismatch; team accepts many ARIA fixes, but tabs-related suggestions
sometimes rejected.

PR-#147
PR-#402

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Tabs compute aria-controls per panel, but the only tabpanel element uses id={panelId}, which is
derived from the currently selected panel, so non-selected tabs inevitably reference IDs not present
in the DOM. Additionally, panel.panelId is used for aria-controls, but panelId is not derived
from panel.panelId.

configurator/src/components/editors/StudioFrame.svelte[15-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`StudioFrame` uses `role="tablist"/"tab"` with `aria-controls` pointing to IDs like `studio-panel-${frameSlug}-${slug(panel.id)}`, but only one element with `role="tabpanel"` exists and its `id` is derived from the *selected* panel. This means only the active tab’s `aria-controls` can match at any given time; all other tabs reference a non-existent element. Also, when a nav item provides `panel.panelId`, that value is used in `aria-controls` but the tabpanel `id` is still computed from `selectedPanel`, so the wiring can never match.

## Issue Context
Current DOM structure implies a full ARIA Tabs pattern but doesn’t provide a stable 1:1 mapping between each tab and its associated tabpanel element.

## Fix Focus Areas
- configurator/src/components/editors/StudioFrame.svelte[15-63]

### Suggested approach
- Introduce a single source of truth for IDs, e.g. `panelDomId(panel)`.
- Either:
 - Render **one tabpanel per nav item** (with stable `id`s), and toggle visibility via `hidden`/CSS based on `selectedPanel`, ensuring each tab’s `aria-controls` always points at an existing element; **or**
 - If the surface is not actually per-tab content, drop `role="tablist"/"tab"/"tabpanel"` semantics and use a navigation pattern instead (e.g., `nav` + buttons/links + `aria-current`).
- If supporting `panel.panelId`, ensure the tabpanel’s `id` uses that same value.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Invalid activePanel breaks focus 🐞 Bug ☼ Reliability
Description
If a parent supplies an activePanel value that isn’t present in nav, selectedPanel will not match
any tab, leaving every tab with tabindex=-1 and aria-selected=false, so the tablist has no
focusable/selected tab. This produces a broken keyboard/a11y state for a controlled StudioFrame.
Code

configurator/src/components/editors/StudioFrame.svelte[R18-53]

+  const firstPanel = $derived(panelNav[0]?.id);
+  const selectedPanel = $derived(activePanel ?? internalActivePanel ?? firstPanel);
+  const selectedPanelSlug = $derived(selectedPanel ? String(selectedPanel).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase() : undefined);
+  const panelId = $derived(selectedPanelSlug ? `studio-panel-${frameSlug}-${selectedPanelSlug}` : undefined);
  const stageStyle = $derived(buildPreviewDeclarations(overrides, ui.previewTheme));
+
+  function selectPanel(panel) {
+    internalActivePanel = panel.id;
+    onSelectPanel?.(panel.id, panel);
+  }
+
+  function getPanelSlug(panel) {
+    return String(panel.id).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
+  }
</script>

<section class="studio studio--{tone}" style={stageStyle}>
  <div class="studio__copy">
    <p>{eyebrow}</p>
    <h3>{title}</h3>
    {#if description}<span>{description}</span>{/if}
-    <ol class="studio__steps" aria-label="Studio workflow">
-      <li>01 Preview</li><li>02 Tune</li><li>03 Verify</li>
-    </ol>
+    {#if sidebar}
+      {@render sidebar?.({ activePanel: selectedPanel, panelId })}
+    {:else if panelNav.length}
+      <nav class="studio__nav" aria-label={`${title} sections`}>
+        <div class="studio__tabs" role="tablist" aria-orientation="vertical" aria-label={`${title} sections`}>
+          {#each panelNav as panel, index (panel.id)}
+            <button
+              type="button"
+              role="tab"
+              class:active={selectedPanel === panel.id}
+              aria-selected={selectedPanel === panel.id}
+              aria-controls={panel.panelId ?? `studio-panel-${frameSlug}-${getPanelSlug(panel)}`}
+              id={`studio-tab-${frameSlug}-${getPanelSlug(panel)}`}
+              tabindex={selectedPanel === panel.id ? 0 : -1}
+              onclick={() => selectPanel(panel)}
Relevance

⭐⭐ Medium

No precedent for invalid controlled activePanel fallback; a11y reliability fixes often accepted but
evidence indirect.

PR-#147
PR-#403

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
selectedPanel prioritizes activePanel even if it doesn’t correspond to any panelNav entry, and
tab focus/selection state is driven exclusively by equality with panel.id, so an invalid
activePanel results in all tabs being non-tabbable and unselected.

configurator/src/components/editors/StudioFrame.svelte[16-53]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`selectedPanel` is derived as `activePanel ?? internalActivePanel ?? firstPanel` without validating that `activePanel` exists in `panelNav`. When it does not exist, none of the rendered tabs become selected or tabbable (`tabindex=-1` for all).

## Issue Context
This is easy to hit when callers treat `activePanel` as controlled state and `nav` changes (or values drift), and it breaks keyboard accessibility.

## Fix Focus Areas
- configurator/src/components/editors/StudioFrame.svelte[16-53]

### Suggested approach
- Compute `validSelectedPanel` by checking membership in `panelNav`:
 - If `activePanel` is defined but not found in `panelNav`, fall back to `firstPanel` (or the first valid panel).
- Ensure at least one tab always has `tabindex=0` (typically the selected tab).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. tabpanel role without tabs 🐞 Bug ≡ Correctness
Description
StudioFrame always renders the surface with role="tabpanel" even when no tablist is rendered (e.g.,
sidebar is provided or nav is empty), which misrepresents the surface in the accessibility tree. In
the empty-nav case, aria-labelledby can also be undefined, leaving the tabpanel unlabeled.
Code

configurator/src/components/editors/StudioFrame.svelte[R39-63]

+    {#if sidebar}
+      {@render sidebar?.({ activePanel: selectedPanel, panelId })}
+    {:else if panelNav.length}
+      <nav class="studio__nav" aria-label={`${title} sections`}>
+        <div class="studio__tabs" role="tablist" aria-orientation="vertical" aria-label={`${title} sections`}>
+          {#each panelNav as panel, index (panel.id)}
+            <button
+              type="button"
+              role="tab"
+              class:active={selectedPanel === panel.id}
+              aria-selected={selectedPanel === panel.id}
+              aria-controls={panel.panelId ?? `studio-panel-${frameSlug}-${getPanelSlug(panel)}`}
+              id={`studio-tab-${frameSlug}-${getPanelSlug(panel)}`}
+              tabindex={selectedPanel === panel.id ? 0 : -1}
+              onclick={() => selectPanel(panel)}
+            >
+              <b>{String(index + 1).padStart(2, '0')}</b>
+              <span>{panel.label}</span>
+            </button>
+          {/each}
+        </div>
+      </nav>
+    {/if}
  </div>
-  <div class="studio__surface">{@render children?.()}</div>
+  <div class="studio__surface" role="tabpanel" id={panelId} aria-labelledby={selectedPanelSlug ? `studio-tab-${frameSlug}-${selectedPanelSlug}` : undefined}>{@render children?.()}</div>
Relevance

⭐⭐ Medium

No historical evidence on role=tabpanel without tablist; team accepts general a11y fixes, unclear
here.

PR-#147
PR-#403

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The nav/tablist is only rendered when sidebar is absent and panelNav.length is truthy, but the
surface is always rendered with role="tabpanel", and its aria-labelledby depends on
selectedPanelSlug which can be undefined.

configurator/src/components/editors/StudioFrame.svelte[39-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The surface container always has `role="tabpanel"`, but the tab UI is conditional (`{#if sidebar} ... {:else if panelNav.length} ...`). When no tabs exist, the component still exposes a tabpanel role (and may have no label).

## Issue Context
This can confuse assistive tech because a `tabpanel` is expected to be part of a tabs relationship.

## Fix Focus Areas
- configurator/src/components/editors/StudioFrame.svelte[39-63]

### Suggested approach
- Only set `role="tabpanel"`, `id`, and `aria-labelledby` when rendering a tabs UI (i.e., when `panelNav.length` and no custom `sidebar` is used for a non-tab navigation).
- Otherwise render the surface as a normal container (no tab roles), or provide an appropriate alternative role/labeling.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +16 to +63
const panelNav = $derived(nav.map((item) => (typeof item === 'string' ? { id: item, label: item } : item)));
const frameSlug = $derived(String(title).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase());
const firstPanel = $derived(panelNav[0]?.id);
const selectedPanel = $derived(activePanel ?? internalActivePanel ?? firstPanel);
const selectedPanelSlug = $derived(selectedPanel ? String(selectedPanel).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase() : undefined);
const panelId = $derived(selectedPanelSlug ? `studio-panel-${frameSlug}-${selectedPanelSlug}` : undefined);
const stageStyle = $derived(buildPreviewDeclarations(overrides, ui.previewTheme));

function selectPanel(panel) {
internalActivePanel = panel.id;
onSelectPanel?.(panel.id, panel);
}

function getPanelSlug(panel) {
return String(panel.id).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
</script>

<section class="studio studio--{tone}" style={stageStyle}>
<div class="studio__copy">
<p>{eyebrow}</p>
<h3>{title}</h3>
{#if description}<span>{description}</span>{/if}
<ol class="studio__steps" aria-label="Studio workflow">
<li>01 Preview</li><li>02 Tune</li><li>03 Verify</li>
</ol>
{#if sidebar}
{@render sidebar?.({ activePanel: selectedPanel, panelId })}
{:else if panelNav.length}
<nav class="studio__nav" aria-label={`${title} sections`}>
<div class="studio__tabs" role="tablist" aria-orientation="vertical" aria-label={`${title} sections`}>
{#each panelNav as panel, index (panel.id)}
<button
type="button"
role="tab"
class:active={selectedPanel === panel.id}
aria-selected={selectedPanel === panel.id}
aria-controls={panel.panelId ?? `studio-panel-${frameSlug}-${getPanelSlug(panel)}`}
id={`studio-tab-${frameSlug}-${getPanelSlug(panel)}`}
tabindex={selectedPanel === panel.id ? 0 : -1}
onclick={() => selectPanel(panel)}
>
<b>{String(index + 1).padStart(2, '0')}</b>
<span>{panel.label}</span>
</button>
{/each}
</div>
</nav>
{/if}
</div>
<div class="studio__surface">{@render children?.()}</div>
<div class="studio__surface" role="tabpanel" id={panelId} aria-labelledby={selectedPanelSlug ? `studio-tab-${frameSlug}-${selectedPanelSlug}` : undefined}>{@render children?.()}</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Tabs reference missing panels 🐞 Bug ≡ Correctness

StudioFrame sets each tab’s aria-controls to a panel-specific id, but only renders a single tabpanel
whose id changes with the currently selected panel, so non-selected tabs point to elements that
don’t exist in the DOM. If a nav item provides panelId, aria-controls can also diverge because the
rendered tabpanel id is not derived from panel.panelId.
Agent Prompt
## Issue description
`StudioFrame` uses `role="tablist"/"tab"` with `aria-controls` pointing to IDs like `studio-panel-${frameSlug}-${slug(panel.id)}`, but only one element with `role="tabpanel"` exists and its `id` is derived from the *selected* panel. This means only the active tab’s `aria-controls` can match at any given time; all other tabs reference a non-existent element. Also, when a nav item provides `panel.panelId`, that value is used in `aria-controls` but the tabpanel `id` is still computed from `selectedPanel`, so the wiring can never match.

## Issue Context
Current DOM structure implies a full ARIA Tabs pattern but doesn’t provide a stable 1:1 mapping between each tab and its associated tabpanel element.

## Fix Focus Areas
- configurator/src/components/editors/StudioFrame.svelte[15-63]

### Suggested approach
- Introduce a single source of truth for IDs, e.g. `panelDomId(panel)`.
- Either:
  - Render **one tabpanel per nav item** (with stable `id`s), and toggle visibility via `hidden`/CSS based on `selectedPanel`, ensuring each tab’s `aria-controls` always points at an existing element; **or**
  - If the surface is not actually per-tab content, drop `role="tablist"/"tab"/"tabpanel"` semantics and use a navigation pattern instead (e.g., `nav` + buttons/links + `aria-current`).
- If supporting `panel.panelId`, ensure the tabpanel’s `id` uses that same value.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +18 to +53
const firstPanel = $derived(panelNav[0]?.id);
const selectedPanel = $derived(activePanel ?? internalActivePanel ?? firstPanel);
const selectedPanelSlug = $derived(selectedPanel ? String(selectedPanel).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase() : undefined);
const panelId = $derived(selectedPanelSlug ? `studio-panel-${frameSlug}-${selectedPanelSlug}` : undefined);
const stageStyle = $derived(buildPreviewDeclarations(overrides, ui.previewTheme));

function selectPanel(panel) {
internalActivePanel = panel.id;
onSelectPanel?.(panel.id, panel);
}

function getPanelSlug(panel) {
return String(panel.id).replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
</script>

<section class="studio studio--{tone}" style={stageStyle}>
<div class="studio__copy">
<p>{eyebrow}</p>
<h3>{title}</h3>
{#if description}<span>{description}</span>{/if}
<ol class="studio__steps" aria-label="Studio workflow">
<li>01 Preview</li><li>02 Tune</li><li>03 Verify</li>
</ol>
{#if sidebar}
{@render sidebar?.({ activePanel: selectedPanel, panelId })}
{:else if panelNav.length}
<nav class="studio__nav" aria-label={`${title} sections`}>
<div class="studio__tabs" role="tablist" aria-orientation="vertical" aria-label={`${title} sections`}>
{#each panelNav as panel, index (panel.id)}
<button
type="button"
role="tab"
class:active={selectedPanel === panel.id}
aria-selected={selectedPanel === panel.id}
aria-controls={panel.panelId ?? `studio-panel-${frameSlug}-${getPanelSlug(panel)}`}
id={`studio-tab-${frameSlug}-${getPanelSlug(panel)}`}
tabindex={selectedPanel === panel.id ? 0 : -1}
onclick={() => selectPanel(panel)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Invalid activepanel breaks focus 🐞 Bug ☼ Reliability

If a parent supplies an activePanel value that isn’t present in nav, selectedPanel will not match
any tab, leaving every tab with tabindex=-1 and aria-selected=false, so the tablist has no
focusable/selected tab. This produces a broken keyboard/a11y state for a controlled StudioFrame.
Agent Prompt
## Issue description
`selectedPanel` is derived as `activePanel ?? internalActivePanel ?? firstPanel` without validating that `activePanel` exists in `panelNav`. When it does not exist, none of the rendered tabs become selected or tabbable (`tabindex=-1` for all).

## Issue Context
This is easy to hit when callers treat `activePanel` as controlled state and `nav` changes (or values drift), and it breaks keyboard accessibility.

## Fix Focus Areas
- configurator/src/components/editors/StudioFrame.svelte[16-53]

### Suggested approach
- Compute `validSelectedPanel` by checking membership in `panelNav`:
  - If `activePanel` is defined but not found in `panelNav`, fall back to `firstPanel` (or the first valid panel).
- Ensure at least one tab always has `tabindex=0` (typically the selected tab).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant